Control showing of notifications on iOS#2156
Conversation
dc78826 to
7b54648
Compare
|
This is ready for review now! |
|
In the pariring call today, Greg metioned that there are some commits here that could be squashed — I'll do that tommorow. (Namely: |
chrisbobbe
left a comment
There was a problem hiding this comment.
Thanks, this is exciting!! Comments below, from reading the commits. I haven't yet done manual testing.
| <key>NSExtensionPointIdentifier</key> | ||
| <string>com.apple.usernotifications.service</string> | ||
| <key>NSExtensionPrincipalClass</key> | ||
| <string>$(PRODUCT_MODULE_NAME).NotificationService</string> |
There was a problem hiding this comment.
ios: Add Notification service app extension target via Xcode
Commit-message nit: I think you mean "NotificationService" or "notification service", not "Notification service"? Depending on whether or not you want to refer to the specific name that appears in the code.
| // NotificationService.swift | ||
| // NotificationService | ||
| // | ||
| // Created by Rajesh Malviya on 18/02/26. |
| INFOPLIST_KEY_CFBundleDisplayName = NotificationService; | ||
| INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||
| IPHONEOS_DEPLOYMENT_TARGET = 26.2; | ||
| IPHONEOS_DEPLOYMENT_TARGET = 15.0; |
There was a problem hiding this comment.
The "iOS Deployment Target" of "Runner" is something we mention in a comment at the top of ios/Podfile:
# This should match the iOS Deployment Target
# (in Xcode, that's in project > Runner > Info)
# and MinimumOSVersion in ios/Flutter/AppFrameworkInfo.plist.
platform :ios, '15.0'Should that comment grow a mention of this new thing that needs to be set to the same value? I believe this is where we declare the minimum supported iOS version, so it's definitely something we expect to change in the future.
| ); | ||
| runOnlyForDeploymentPostprocessing = 0; | ||
| shellPath = /bin/sh; | ||
| shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; |
There was a problem hiding this comment.
ios: In Xcode, add Run Script build phase for the extension target
Hmm, just above this B378A52C2F45C31A0031EFA1 /* ShellScript */ = { block is a nearly identical block 9740EEB61CF901F6004384FC /* Run Script */ = { which has the same shellScript value. Are they both needed? I wonder if the problem was just that the old way of expressing the desired build phase wasn't working.
There was a problem hiding this comment.
The previous 9740EEB61CF901F6004384FC /* Run Script */ block is referenced by the Runner target, and B378A52C2F45C31A0031EFA1 /* ShellScript */ is being added by this workaround/step for the NotificationService target (Not sure why the (commented) name generated here is different, in Xcode both phases, under TARGETS > [Runner/NotificationService] > Build Phases (tab) > Run Script, are called "Run Script".
| // `assert()` statements are not working in the extension even when running in | ||
| // the debug mode. But fortunately `kDebugMode` does correctly return true in | ||
| // the debug mode and false in the release mode. So, use this helper instead of | ||
| // `debugLog` from `log.dart`. |
There was a problem hiding this comment.
Maybe worth a // TODO debug asserts not working on this.
| // current thread is being polled by the system. Which is not the case in | ||
| // Notification Service Extension, so here we manually poll the event loop. | ||
| // See discussion: | ||
| // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/Running.20Dart.20code.20in.20iOS.20Notification.20Service.20Extension/with/2370721 |
There was a problem hiding this comment.
Will there be a change to be made here if a PR like flutter/flutter#181645 lands? If so, let's add a TODO. (I'm not familiar with all the details but I found that PR by following links.)
| // This library defines the Dart entrypoint function for headless FlutterEngine | ||
| // used in iOS Notification Service Extension. We need to import it here to | ||
| // avoid it being treeshaked during build process. | ||
| // ignore: unused_import | ||
| import 'notifications/ios_service.dart'; |
There was a problem hiding this comment.
I wonder if this is the standard way to prevent something from being treeshaken, or if there's some config in the build process for this?
There was a problem hiding this comment.
I believe @pragma('vm:entry-point') decorator prevents the top-level function from being tree-shaked but AIUI there's no way to prevent the whole unimported dart library from being tree-shaked. (I realize tree-shaked is not the correct term, it's more that the dart library is not imported anywhere so it's not included in the build.)
| guard let bestAttemptContent = bestAttemptContent else { | ||
| return | ||
| } |
There was a problem hiding this comment.
What is this early return doing? I'm not sure if this needs a comment or if I just need to learn more Swift. 🙂
| switch result { | ||
| case .success(let mutatedNotificationContent): | ||
| os_log("didReceivePushNotification: success") | ||
| bestAttemptContent.title = mutatedNotificationContent.title | ||
| if let body = mutatedNotificationContent.body { | ||
| bestAttemptContent.body = body | ||
| } | ||
| contentHandler(bestAttemptContent) | ||
|
|
||
| case .failure(let error): | ||
| os_log("didReceivePushNotification: failed: error=\(error.localizedDescription)") | ||
| contentHandler(bestAttemptContent) | ||
| } | ||
| } |
There was a problem hiding this comment.
Again, this switch/case indentation looks odd to me.
| final Map<Object?, Object?> payload; | ||
| } | ||
|
|
||
| class MutatedNotificationContent { |
There was a problem hiding this comment.
I wonder if "mutated" is the name we want here, or if there's something more specific we want to emphasize? The goal is to make "better"/"improved" notification content, right, not just different/changed content. 😛 #1265 says "improve the wording and formatting of notifications".
There was a problem hiding this comment.
I had used MutatedNotificationContent name to correspond to UNMutableNotificationContent. Changed to "ImprovedNotificationContent".
| Future<void> toggleWakelock({required bool enable}); | ||
|
|
||
| /// The iOS App Group identifier specified in the Xcode config. | ||
| static const iosAppGroupIdentifier = 'group.zulip.test'; |
There was a problem hiding this comment.
This will eventually not say "test", right? Do we need a comment on the consequences of changing this in future (e.g. data loss?)
|
|
||
| IosNotifFlutterApi iosNotifFlutterApi() => TestZulipBinding.instance.iosNotifFlutterApi!; | ||
|
|
||
| test('smoke', () async { |
There was a problem hiding this comment.
This needs a addTearDown(testBinding.reset);, I think.
| IosNotifFlutterApi iosNotifFlutterApi() => TestZulipBinding.instance.iosNotifFlutterApi!; | ||
|
|
||
| test('smoke', () async { | ||
| IosNotificationService.init(); |
There was a problem hiding this comment.
IosNotificationService has mutable state (isExecutingInExtension) which we don't want to leak between tests; probably it should have a reset method that tests call on teardown?
|
|
||
| /// The file path to use for the app database. | ||
| static Future<File> _dbFile() async { | ||
| if (defaultTargetPlatform == TargetPlatform.iOS) return _maybeCopyIosDbFile(); |
There was a problem hiding this comment.
Below this new early return, the comment starting with "What directory should we use?" now looks confusing:
- It's a long comment, so it seems like the question it's asking doesn't have a simple answer.
- …But the question has apparently been answered anyway, at least for iOS.
- The answer still includes "on iOS" cases.
| if (IosNotificationService.isExecutingInExtension) { | ||
| // In iOS Notification Service Extension we can't copy the file, as the | ||
| // extension will not have access to main app target's filesystem. | ||
| // We also can't return the App Group container path here when running in | ||
| // the extension, because it will cause creation of new empty DB file, | ||
| // which then the main app target will prefer because of the code above. | ||
| // So, abort if we are running in iOS Notification Service Extension and | ||
| // the database is not already migrated to the App Group container. | ||
| throw Exception('The database file in iOS app group container doesn\'t ' | ||
| 'exists, and when running in iOS Notification Service Extension the ' | ||
| 'database file can\'t be migrated'); | ||
| } |
There was a problem hiding this comment.
I think some of this comment and error message can be removed/simplified if we make the dartdoc more explicit about why the things it does are needed.
Can we make the dartdoc clear that
- This is essentially a one-time migration that enables the extension to work. The extension won't work without it because [etc.].
- The migration can only be run by the app, not the app extension, because [etc.].
- If this runs in the app extension before the migration has been done, [etc.] happens.
This reminds me of the data migration from the legacy app, right? Though less complex, because we're just straightforwardly copying a file instead of restructuring data and so on. Still, are there details from that former migration that would be helpful to think about here? (Like how to organize the code?) Do we need to set a flag somewhere tracking whether the migration is complete, like we do for other migrations? Would some test coverage be feasible? Etc. It would be embarrassing to cause data loss for people by mishandling a migration somehow. 🙂
There was a problem hiding this comment.
Added the required context to the dartdoc.
Do we need to set a flag somewhere tracking whether the migration is complete, like we do for other migrations?
I don't think this is needed because it is only a single operation (copying the file), and would only be executed from Runner target, so no race condition between Runner and NotificationService.
Would some test coverage be feasible
Not sure how best to unit test this type of file copy migrations, if we did mocks for file system api that it would not really be testing actual code.
It would be embarrassing to cause data loss for people by mishandling a migration somehow.
Agreed.
| 'to iOS app group container path: $containerDbFile')); | ||
| await nonContainerDbFile.copy(containerDbFile.path); | ||
|
|
||
| // TODO do we want to delete nonContainerDbFile here? |
There was a problem hiding this comment.
This seems like the kind of detail that we'd want to have a plan for, even if we don't do it right now, before merging this migration.
09ffdb5 to
3fbbe3b
Compare
gnprice
left a comment
There was a problem hiding this comment.
Thanks! Here's a handful of comments from an initial skim. (I realize this hasn't been marked as fully revised for Chris's comments above.)
| @@ -0,0 +1,239 @@ | |||
| // Autogenerated from Pigeon (v26.1.7), do not edit directly. | |||
There was a problem hiding this comment.
This new generated file should be added to .gitattributes so that it gets collapsed in git diff.
(Better: a pattern that covers it and similar files.)
| // TODO rename the group identifier. | ||
| const iosAppGroupIdentifier = 'group.zulip.test'; |
There was a problem hiding this comment.
Maybe group.zulip.app? Seems like this group is basically "the Zulip app, as a whole".
| if (defaultTargetPlatform == TargetPlatform.iOS) { | ||
| file = await _maybeCopyIosDbFile(); | ||
| } else { | ||
| file = await _dbFile(); |
There was a problem hiding this comment.
This complication seems like something that's not part of this caller's job to worry about. It should go inside the method this code is calling, so either _dbFile or some new method.
| 'database file can\'t be migrated'); | ||
| } | ||
|
|
||
| final nonContainerDbFile = await _dbFile(); |
There was a problem hiding this comment.
Echoing and expanding a bit on what Chris said at #2156 (comment) : definitely take the code in _dbFile as open to revision with the changes you're making here. The filename that the existing logic there produces is no longer really "the DB file" for the app on iOS; so either the method's name, or its logic, or both, should change. Likely the comment in it should then change too. The goal is for the new code after your changes to have a logic and structure that's clear to the reader, even without them having seen what the code looked like previously.
948d69c to
8e328fb
Compare
|
Thanks for the reviews @chrisbobbe, @gnprice! Pushed a new revision, PTAL. |
|
Would you try the following:
Possibly that step 4 should be changed. It seems odd to add the pre-action only on the scheme dedicated to building the app extension in isolation. In our case, at least, I think we can just delete that (the auto-generated "NotificationService" scheme)… Our default scheme, "Runner", which has become a confusing name, builds both the "Runner" target and the "NotificationService" target, which is probably always what we want. The goal is to avoid running the heavier See flutter/flutter#146256 (which is basically the same solution to this problem as it appears in a different context):
|
|
If that works, then try reverting step 3 of https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter :
That seems to be a security feature that applies to script build phases: you limit the script's permissions to specific input and output files. But the instructions don't ask you to add a script build phase, and if my suggestion above worked, you won't have added any. I don't think there's a version of this feature that applies to a pre-action script. In the UI to configure a pre-action script, there's no way to specify input/output files. |
|
For step 5 of https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter:
would you make a separate commit for that (or maybe squash it into the commit that creates the new target)? It's an interesting change because:
Probably the app extension's Flutter behavior depends on some of that config. But we might find out later that these defaults aren't a perfect fit for app extensions; it won't be top-of-mind when Flutter makes changes to it. |
|
|
This moves IPHONEOS_DEPLOYMENT_TARGET config to Zulip.xcconfig, instead of it being specified for different Xcode config sections. This vastly simplifies changing the iOS deployment target later, allowing us to change a single variable, instead of navigating through the Xcode UI to change for multiple targets (two currently; Runner and RunnerTests). See: zulip#2156 (comment)
This carries out the steps 3-4 from guide for "Add iOS app extension" section in the docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#add-extension Then ran 'swift-format' over the generated Swift code. (In the Xcode menu bar, Editor -> Structure -> Format file with 'swift-format'.) Finally, this also carries out step 5 from "Open a Flutter app in an iOS app extension" section in the docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter which shares the build configurations between extension target and Runner target. Allowing the extension target to: - Inherit the IPHONEOS_DEPLOYMENT_TARGET config value, from Zulip.xcconfig which {Debug,Release}.xcconfig imports. - Also it pulls in some build config specified by Flutter, including some CocoaPods-related stuff; see for example ios/Flutter/Release.xcconfig. See: zulip#2156 (comment)
…xtension Implement initial setup for headless FlutterEngine for running Dart code in NotificationService extension. This carries out the step 5 from guide for "Open a Flutter app in an iOS app extension" section in the docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter This carries out the steps 2-3 from guide for "Register plugins" section in the docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#register-plugins Additionally adds the following pre-action script for the Runner scheme: /bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare This script copies the Flutter framework to a place where Xcode will find it when building the NotificationService target. See discussion: zulip#2156 (comment)
8e328fb to
3bb22d2
Compare
01a3b25 to
d463532
Compare
This moves IPHONEOS_DEPLOYMENT_TARGET config to Zulip.xcconfig, instead of it being specified for different Xcode config sections. This vastly simplifies changing the iOS deployment target later, allowing us to change a single variable, instead of navigating through the Xcode UI to change for multiple targets (two currently; Runner and RunnerTests). See: zulip#2156 (comment)
Carries out the steps 3-4 from "Add iOS app extension" section in the Flutter docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#add-extension In step 3, the Flutter docs walk through adding a "Share Extension" target, we instead add a "Notification Service Extension" target, as described in Apple's documentation: https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications#Add-a-service-app-extension-to-your-project The Swift file generated in step 3 was formatted using `swift-format` (In Xcode menu bar, Editor -> Structure -> Format file with 'swift-format'). Also carries out step 5 from the "Open a Flutter app in an iOS app extension" section in the Flutter docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter this will share the build configurations between the extension target and the Runner target, allowing the extension target to: - Inherit the IPHONEOS_DEPLOYMENT_TARGET config value, from Zulip.xcconfig which {Debug,Release}.xcconfig imports. - Also it pulls in some build config specified by Flutter, including some CocoaPods-related stuff; see for example ios/Flutter/Release.xcconfig. See discussion: zulip#2156 (comment) Also made following other changes to Xcode build settings for the generated NotificationService target: - Deleted "iOS Deployment Target" config specified for target, so that when building the target, the inherited value from Zulip.xcconfig will be used. By navigating to project navigator (View > Navigators > Project) > NotificationService under TARGETS > Build Settings (tab) > Deployment, then pressed the Delete key on keyboard after selecting "iOS Deployment Target". - Updated the versioning information to match the Runner target. By navigating to project navigator (View > Navigators > Project) > NotificationService under TARGETS > Build Settings (tab) > Versioning, and changing the following values: * Changed "Current Project Version" to "$(FLUTTER_BUILD_NUMBER)" (in Xcode text field, typed without the double-quotes), where the generated value was "1". * Changed "Marketing Version" to "$(FLUTTER_BUILD_NAME)" (same as previous one, no double-quotes), where the default value was "1.0". * Changed "Versioning System" to "Apple Generic", where the default was set to "None". See discussion: zulip#2156 (comment)
…xtension Implement initial setup of a headless FlutterEngine for running Dart code in the NotificationService extension. This involves some Swift code to start the engine and drive its event loop, and a trivial Dart function to serve as the entry point. It also involves some build setup. For the build setup, this carries out the steps 2-3 from the "Register plugins" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#register-plugins and adds the following pre-action script for the Runner scheme: /bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare by following same steps listed under step 4 in "Open a Flutter app in an iOS app extension" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter but for the Runner scheme instead of the extension scheme. And also selected the Runner target instead of the extension target for the "Provide build settings" drop-down list. That script copies the Flutter framework to a place where Xcode will find it when building the NotificationService target. See discussion: zulip#2156 (comment) We are skipping the step 3 of "Open a Flutter app in an iOS app extension": https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter because disabling user script sandboxing is only needed if we had added a "Run Script" build phase without specifying input and output files. It seems it is unnecessary for the pre-action script because Xcode UI for adding the script doesn't have the options for specifying input and output files. See discussion: zulip#2156 (comment)
d463532 to
fbe9e8d
Compare
|
Thanks for the review @gnprice! Pushed a new revision, including a new commit which removes the simlated push notification docs, PTAL. |
gnprice
left a comment
There was a problem hiding this comment.
Thanks for the revision! Just small comments below, plus what I said in the chat thread at #mobile-team > managing iOS notifications @ 💬:
Please write these instructions down in a file in docs/ and include that in the PR branch. It's important that people be able to do development on this code.
I've also now successfully followed those instructions and demonstrated this PR working 🎉:
- On a physical iPhone, with
flutter run --release, with an edit to theImprovedNotificationContentconstructor call so as to alter the notification title, the notifications show up with that alteration. - In the simulator, with plain
flutter runso a debug build, the same observation plus there are log messages in Console.app from the new Dart code.
| return true; | ||
| }()); | ||
|
|
||
| assert(debugLog('dart: iosNotificationServiceMain')); |
There was a problem hiding this comment.
Are these "dart:" prefixes needed? When I run this, the lines I see in Console.app look like "flutter: dart: iosNotificationServiceMain"; seems like "flutter:" is already signalling where this comes from.
(And in general we don't put that kind of prefix into our log messages.)
| // Adapted from: https://github.com/flutter/flutter/blob/65b1ec407/engine/src/flutter/fml/platform/darwin/message_loop_darwin.mm#L44-L62 | ||
| let kDistantFuture = 1.0e10 | ||
| while loopRunning { | ||
| let result = CFRunLoopRunInMode(.defaultMode, kDistantFuture, true) | ||
| if result == .stopped || result == .finished { | ||
| loopRunning = false | ||
| } | ||
| } |
There was a problem hiding this comment.
Cool. Writing down here what I said on our call today: I think the key thing I'd want here, then, is just to make these two versions look as similar as possible in the ways that don't matter, so that the change that does matter stands out better.
The switch/case seems like a good idea, since it helps make visible what the different result values are here.
| /// Wraps [IosNotifFlutterApi.setUp], provided an implementation of | ||
| /// [IosNotifFlutterApi] whose methods will be called from native code | ||
| /// (Swift in this case). | ||
| void setupIosNotifFlutterApi(IosNotifFlutterApi api); |
There was a problem hiding this comment.
The part about how IosNotifFlutterApi will get called seems like it's really a fact about that class.
Hmm in fact: that class should get some dartdoc. 🙂 (You can write it in the Pigeon file, and it'll get copied to the generated class.)
| @FlutterApi() | ||
| abstract class IosNotifFlutterApi { | ||
| @async | ||
| ImprovedNotificationContent didReceivePushNotification(NotificationContent content); |
There was a problem hiding this comment.
This class and method should each get some dartdoc. For examples, see pigeon/android_notifications.dart.
| final Map<Object?, Object?> payload; | ||
| } | ||
|
|
||
| class ImprovedNotificationContent { |
There was a problem hiding this comment.
Similarly these two data classes.
| final title = alertData['title'] as String; | ||
| final body = alertData['body'] as String?; | ||
|
|
||
| return ImprovedNotificationContent(title: title, body: body); |
| (_notificationPigeonApi ??= FakeNotificationPigeonApi()); | ||
| FakeNotificationPigeonApi? _notificationPigeonApi; | ||
|
|
||
| IosNotifFlutterApi? get iosNotifFlutterApi => _iosNotifFlutterApi; |
There was a problem hiding this comment.
This is part of the API that test code is meant to use, so it should get some dartdoc (just like the other members that TestZulipBinding introduces as new API).
| (_notificationPigeonApi ??= FakeNotificationPigeonApi()); | ||
| FakeNotificationPigeonApi? _notificationPigeonApi; | ||
|
|
||
| IosNotifFlutterApi? get iosNotifFlutterApi => _iosNotifFlutterApi; |
There was a problem hiding this comment.
nit: Basically all callers of this getter are going to want to assume this has been set, right? So we can simplify that for them:
| IosNotifFlutterApi? get iosNotifFlutterApi => _iosNotifFlutterApi; | |
| IosNotifFlutterApi get iosNotifFlutterApi => _iosNotifFlutterApi!; |
| abstract class IosNotificationService { | ||
| const IosNotificationService._(); |
There was a problem hiding this comment.
Stray constructor. Looks like a mismerge — it pops up in the main commit, after the class appears. Here's from git range-diff:
-@visibleForTesting
- class IosNotificationService {
- const IosNotificationService._();
-
+ abstract class IosNotificationService {
++ const IosNotificationService._();
++
+ /// Whether the currently executing context is
+ /// iOS notification service app extension.
+ static bool isExecutingInExtension = false;
This moves IPHONEOS_DEPLOYMENT_TARGET config to Zulip.xcconfig, instead of it being specified for different Xcode config sections. This vastly simplifies changing the iOS deployment target later, allowing us to change a single variable, instead of navigating through the Xcode UI to change for multiple targets (two currently; Runner and RunnerTests). See: zulip#2156 (comment)
Carries out the steps 3-4 from "Add iOS app extension" section in the Flutter docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#add-extension In step 3, the Flutter docs walk through adding a "Share Extension" target, we instead add a "Notification Service Extension" target, as described in Apple's documentation: https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications#Add-a-service-app-extension-to-your-project The Swift file generated in step 3 was formatted using `swift-format` (In Xcode menu bar, Editor -> Structure -> Format file with 'swift-format'). Also carries out step 5 from the "Open a Flutter app in an iOS app extension" section in the Flutter docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter this will share the build configurations between the extension target and the Runner target, allowing the extension target to: - Inherit the IPHONEOS_DEPLOYMENT_TARGET config value, from Zulip.xcconfig which {Debug,Release}.xcconfig imports. - Also it pulls in some build config specified by Flutter, including some CocoaPods-related stuff; see for example ios/Flutter/Release.xcconfig. See discussion: zulip#2156 (comment) Also made following other changes to Xcode build settings for the generated NotificationService target: - Deleted "iOS Deployment Target" config specified for target, so that when building the target, the inherited value from Zulip.xcconfig will be used. By navigating to project navigator (View > Navigators > Project) > NotificationService under TARGETS > Build Settings (tab) > Deployment, then pressed the Delete key on keyboard after selecting "iOS Deployment Target". - Updated the versioning information to match the Runner target. By navigating to project navigator (View > Navigators > Project) > NotificationService under TARGETS > Build Settings (tab) > Versioning, and changing the following values: * Changed "Current Project Version" to "$(FLUTTER_BUILD_NUMBER)" (in Xcode text field, typed without the double-quotes), where the generated value was "1". * Changed "Marketing Version" to "$(FLUTTER_BUILD_NAME)" (same as previous one, no double-quotes), where the default value was "1.0". * Changed "Versioning System" to "Apple Generic", where the default was set to "None". See discussion: zulip#2156 (comment)
…xtension Implement initial setup of a headless FlutterEngine for running Dart code in the NotificationService extension. This involves some Swift code to start the engine and drive its event loop, and a trivial Dart function to serve as the entry point. It also involves some build setup. For the build setup, this carries out the steps 2-3 from the "Register plugins" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#register-plugins and adds the following pre-action script for the Runner scheme: /bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare by following same steps listed under step 4 in "Open a Flutter app in an iOS app extension" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter but for the Runner scheme instead of the extension scheme. And also selected the Runner target instead of the extension target for the "Provide build settings" drop-down list. That script copies the Flutter framework to a place where Xcode will find it when building the NotificationService target. See discussion: zulip#2156 (comment) We are skipping the step 3 of "Open a Flutter app in an iOS app extension": https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter because disabling user script sandboxing is only needed if we had added a "Run Script" build phase without specifying input and output files. It seems it is unnecessary for the pre-action script because Xcode UI for adding the script doesn't have the options for specifying input and output files. See discussion: zulip#2156 (comment)
2fe7b97 to
469d106
Compare
…xtension Implement initial setup of a headless FlutterEngine for running Dart code in the NotificationService extension. This involves some Swift code to start the engine and drive its event loop, and a trivial Dart function to serve as the entry point. It also involves some build setup. For the build setup, this carries out the steps 2-3 from the "Register plugins" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#register-plugins and adds the following pre-action script for the Runner scheme: /bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare by following same steps listed under step 4 in "Open a Flutter app in an iOS app extension" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter but for the Runner scheme instead of the extension scheme. And also selected the Runner target instead of the extension target for the "Provide build settings" drop-down list. That script copies the Flutter framework to a place where Xcode will find it when building the NotificationService target. See discussion: zulip#2156 (comment) We are skipping the step 3 of "Open a Flutter app in an iOS app extension": https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter because disabling user script sandboxing is only needed if we had added a "Run Script" build phase without specifying input and output files. It seems it is unnecessary for the pre-action script because Xcode UI for adding the script doesn't have the options for specifying input and output files. See discussion: zulip#2156 (comment)
469d106 to
768da83
Compare
|
Thanks for the review @gnprice! Pushed a new revison, PTAL. |
| case .timedOut: | ||
| // Ideally this should be unreachable because the time out is much | ||
| // larger than the 30 second limit for this function. But continue | ||
| // looping here, matching the upstream implementation. | ||
| continue |
There was a problem hiding this comment.
The timeout is 1e10 seconds. That's over 300 years. 🙂 We don't need to try to reason about things like a 30-second limit for notification handling to conclude that this shouldn't ever happen.
| case .failure(let error): | ||
| contentHandler(bestAttemptContent) // TODO(log) |
There was a problem hiding this comment.
Xcode gives me a warning here:
Immutable value 'error' was never used; consider replacing with '_' or removing it
| @unknown default: | ||
| fatalError() |
There was a problem hiding this comment.
So if I delete this case, the warning I get reads:
Switch covers known cases, but 'CFRunLoopRunResult' may have additional unknown values, possibly added in future versions; this is an error in the Swift 6 language mode
If a future iOS version adds some new value for this enum, do we really want a fatal error here? That doesn't seem right.
Instead let's do a // TODO(log) and either continue the loop or break out of it, not sure which.
| // TODO let FlutterEngine itself handle this, or expose an API that makes | ||
| // this easier, maybe with something like: | ||
| // https://github.com/flutter/flutter/pull/181645 |
There was a problem hiding this comment.
This is calling for something that would be a change upstream, right? Let's use our little notation for indicating that:
| // TODO let FlutterEngine itself handle this, or expose an API that makes | |
| // this easier, maybe with something like: | |
| // https://github.com/flutter/flutter/pull/181645 | |
| // TODO(upstream) let FlutterEngine itself handle this, or expose an API that makes | |
| // this easier, maybe with something like: | |
| // https://github.com/flutter/flutter/pull/181645 |
| /// The new title to use for the notification. | ||
| final String title; | ||
|
|
||
| /// The new body to use for the notification. | ||
| final String? body; |
There was a problem hiding this comment.
What's the meaning of body having a value of null? What's the motivation for body being nullable but title not?
There was a problem hiding this comment.
In the previous revision, the reason body was nullable was because the E2EE payload only set the title key (to the value "New notification") (See docs). But I realize now that it is a detail that is not needed for this PR, because legacy payloads have both of these keys and that is what this PR demo-ing with anyway.
It will also not be needed for the next PR (#2230), because we would override the existing title and body anyway.
Also, UNMutableNotificationContent.body doesn't allow setting it to null. (I guess the API opts for empty strings to indicate absence?)
Anyways made the body non-nullable.
| bestAttemptContent = | ||
| (request.content.mutableCopy() as? UNMutableNotificationContent) | ||
|
|
||
| guard let bestAttemptContent = bestAttemptContent else { | ||
| contentHandler(request.content) // TODO(log) | ||
| return | ||
| } |
There was a problem hiding this comment.
Can mutableCopy actually return nil? If not, we can simplify this logic.
There was a problem hiding this comment.
(Pushed a revision with the space between both statements removed, as discussed on call.)
There was a problem hiding this comment.
Cool. Writing down here the answer you gave on the call to my question above:
This guard isn't (just?) for mutableCopy returning nil, but for it returning an object not of type UNMutableNotificationContent. In that case, the as? would return nil.
That still seems probably impossible, though the docs aren't real clear about that. (The return type of mutableCopy is just Any; it's inherited from a more general interface, and that interface isn't very crisply typed.)
But this check is there in the template code that Xcode supplies, seen in an earlier commit in this PR. So OK, we'll follow that lead and keep the check.
| // Currently the non-E2EE APNs payloads do not trigger the | ||
| // NotificationService app extension, because they do not contain the | ||
| // required key-pair ('mutable-content': 1). Meaning this code is never | ||
| // executed by an unmodified Zulip Server, currently, it only exists as | ||
| // a scaffold for implementing support for E2EE notifications (#1764). | ||
| // That is why we are passing the title and body as-is here, to serve as | ||
| // a demo that #1265 is complete. | ||
| return ImprovedNotificationContent(title: title, body: body); |
There was a problem hiding this comment.
The logic of this explanation doesn't totally make sense to me; I'll try revising it at merge time.
There was a problem hiding this comment.
Edited like so:
// This doesn't ultimately have any effect: it returns the same
// title and body that the notification already had, so nothing changes.
// Moreover this code doesn't run in the first place when talking to a
// normal Zulip server, because the non-E2EE APNs payloads
// don't contain the flag `'mutable-content': 1` and so
// don't trigger the NotificationService app extension.
//
// The purpose of this code is to be a checkpoint on the way to supporting
// E2EE notifications (#1764) and more generally client-side control over
// notification behavior (#1265). It can be manually tested with
// a server-side edit to set the `mutable-content` flag, plus other steps:
// https://github.com/zulip/zulip-flutter/pull/2156#pullrequestreview-4085925962
This moves IPHONEOS_DEPLOYMENT_TARGET config to Zulip.xcconfig, instead of it being specified for different Xcode config sections. This vastly simplifies changing the iOS deployment target later, allowing us to change a single variable, instead of navigating through the Xcode UI to change for multiple targets (two currently; Runner and RunnerTests). See: zulip#2156 (comment)
Similar to previous commit, this moves DEVELOPMENT_TEAM config to Zulip.xcconfig, instead of it being specified for different Xcode config sections.
In the Xcode menu bar, Editor -> Structure -> Format file with 'swift-format'.
Carries out the steps 3-4 from "Add iOS app extension" section in the Flutter docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#add-extension In step 3, the Flutter docs walk through adding a "Share Extension" target, we instead add a "Notification Service Extension" target, as described in Apple's documentation: https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications#Add-a-service-app-extension-to-your-project The Swift file generated in step 3 was formatted using `swift-format` (In Xcode menu bar, Editor -> Structure -> Format file with 'swift-format'). Also carries out step 5 from the "Open a Flutter app in an iOS app extension" section in the Flutter docs: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter this will share the build configurations between the extension target and the Runner target, allowing the extension target to: - Inherit the IPHONEOS_DEPLOYMENT_TARGET config value, from Zulip.xcconfig which {Debug,Release}.xcconfig imports. - Also it pulls in some build config specified by Flutter, including some CocoaPods-related stuff; see for example ios/Flutter/Release.xcconfig. See discussion: zulip#2156 (comment) Also made following other changes to Xcode build settings for the generated NotificationService target: - Deleted "iOS Deployment Target" config specified for target, so that when building the target, the inherited value from Zulip.xcconfig will be used. By navigating to project navigator (View > Navigators > Project) > NotificationService under TARGETS > Build Settings (tab) > Deployment, then pressed the Delete key on keyboard after selecting "iOS Deployment Target". - Updated the versioning information to match the Runner target. By navigating to project navigator (View > Navigators > Project) > NotificationService under TARGETS > Build Settings (tab) > Versioning, and changing the following values: * Changed "Current Project Version" to "$(FLUTTER_BUILD_NUMBER)" (in Xcode text field, typed without the double-quotes), where the generated value was "1". * Changed "Marketing Version" to "$(FLUTTER_BUILD_NAME)" (same as previous one, no double-quotes), where the default value was "1.0". * Changed "Versioning System" to "Apple Generic", where the default was set to "None". See discussion: zulip#2156 (comment)
…xtension Implement initial setup of a headless FlutterEngine for running Dart code in the NotificationService extension. This involves some Swift code to start the engine and drive its event loop, and a trivial Dart function to serve as the entry point. It also involves some build setup. For the build setup, this carries out the steps 2-3 from the "Register plugins" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#register-plugins and adds the following pre-action script for the Runner scheme: /bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare by following same steps listed under step 4 in "Open a Flutter app in an iOS app extension" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter but for the Runner scheme instead of the extension scheme. And also selected the Runner target instead of the extension target for the "Provide build settings" drop-down list. That script copies the Flutter framework to a place where Xcode will find it when building the NotificationService target. See discussion: zulip#2156 (comment) We are skipping the step 3 of "Open a Flutter app in an iOS app extension": https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter because disabling user script sandboxing is only needed if we had added a "Run Script" build phase without specifying input and output files. It seems it is unnecessary for the pre-action script because Xcode UI for adding the script doesn't have the options for specifying input and output files. See discussion: zulip#2156 (comment)
768da83 to
dc66f9f
Compare
…xtension Implement initial setup of a headless FlutterEngine for running Dart code in the NotificationService extension. This involves some Swift code to start the engine and drive its event loop, and a trivial Dart function to serve as the entry point. It also involves some build setup. For the build setup, this carries out the steps 2-3 from the "Register plugins" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#register-plugins and adds the following pre-action script for the Runner scheme: /bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare by following same steps listed under step 4 in "Open a Flutter app in an iOS app extension" section in Flutter's guide to iOS app extensions: https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter but for the Runner scheme instead of the extension scheme. And also selected the Runner target instead of the extension target for the "Provide build settings" drop-down list. That script copies the Flutter framework to a place where Xcode will find it when building the NotificationService target. See discussion: zulip#2156 (comment) We are skipping the step 3 of "Open a Flutter app in an iOS app extension": https://docs.flutter.dev/platform-integration/ios/app-extensions#creating-app-extension-uis-with-flutter because disabling user script sandboxing is only needed if we had added a "Run Script" build phase without specifying input and output files. It seems it is unnecessary for the pre-action script because Xcode UI for adding the script doesn't have the options for specifying input and output files. See discussion: zulip#2156 (comment)
|
Thanks for the review @gnprice! Pushed an update, PTAL. |
Fixes zulip#1265. This uses Pigeons's handy `@FlutterApi` API to define an interface method that allows passing arguments from Swift to Dart and also get a return value back from Dart to Swift.
…roup This is required to share any files like database between both app (Runner) and the service extension targets.
This will allow the Dart code executing in the notification service extension to access the same database file as the main app target.
…ulator Our notification code on iOS now relies on using notification service app extension to be able to modify incoming push notifications. These instructions walk through how to trigger a simulated (fake) push notification on iOS Simulator, and this simulated (fake) notification do not trigger the notification service app extension. See: https://stackoverflow.com/a/79707485 Meaning these instructions are not useful anymore, so remove them.
|
Thanks @rajveermalviya for all your work on this! Merging, with a comment edited as described at #2156 (comment) . |

Testing
iOS Notification Service doesn't execute unless the push notification has the
"mutable-content" : 1property under"aps"object.Zulip server doesn't set this property in non-e2ee push notifications, so the dev server will need the patch below to do the testing:
Additionally, the iOS Notification Service doesn't execute with the simulated push notifications as described in "Testing Push Notifications on iOS Simulator" docs. It only executes if the push notification is a real push notification either from the Sandbox/Production APNs server. So, the dev server will need to setup those as documented here.
Fixes: #1265